26. Node.js - JavaScript poza przeglądarką

Wyzwania:

  • dowiesz się jak kompilować JS-a poza przeglądarką,
  • zbudujesz swoje pierwsze desktopowe aplikacje,
  • opublikujesz własną paczkę w repozytorium NPM-a.

Wstęp

Dotychczas wszystkie nasze projekty uruchamialiśmy za pomocą przeglądarki. Otwieraliśmy w niej plik .html, a ta zajmowała się resztą – wczytywała potrzebne style, obrazki, a przede wszystkim zajmowała się kompilacją i uruchomieniem skryptów JS-owych. Działo się tak nawet wtedy, kiedy całość naszej aplikacji była bundlowana za pomocą Webpacka. Wtedy też otrzymywaliśmy jeden większy plik JS (najczęściej maksymalnie skompresowany) i ostatecznie był on kompilowany przez przeglądarkę dopiero przy podglądzie.

Czy jest to złe rozwiązanie? Bardzo często wręcz przeciwnie – wydaje się optymalne i dość wygodne. Taki sposób uruchamiania naszych projektów jest bardzo prosty. Co prawda wiemy o różnicach między wspieranymi standardami przez kolejne wersje przeglądarek, ale ta wada może być łatwo niwelowana poprzez użycia wspomnianego wcześniej Webpacka.

Niemniej jednak takie podejście do kompilacji naszego kodu ma też pewne ograniczenia. Jakie?

Przede wszystkim, przeglądarka nie daje nam możliwości bezpośredniego uruchomienia plików JS. Nawet jeśli nie ma w nich żadnych instrukcji, które wymagałyby kontaktu z DOM-em, to i tak musimy przygotować przynajmniej pusty plik HTML, dodać w nim import naszego skryptu i dopiero uruchomienie go w przeglądarce skompiluje nasz kod JS. Próba otwarcia samego pliku z rozszerzeniem .js spowoduje tylko pokazanie jego zawartości.

Drugie ograniczenie jest poważniejsze. O ile sama przeglądarka, jako program desktopowy, ma dostęp do systemu, to my nie mamy takiej możliwości z poziomu naszego kodu JS. Nie możemy więc zapisywać, odczytywać czy usuwać plików. Nie mamy też możliwości odczytu zawartości katalogów czy tworzenia ich. Brakuje nam również dostępu do informacji o użytkowniku komputera. Przeglądarka posiada taki dostęp, jednak dla naszych skryptów jest on bardzo ograniczany. Pozbawia nas to mnóstwa możliwości – edytor tekstu, eksplorator plików, czy wiele innych pomysłów na aplikacje, nie może być w takim scenariuszu zrealizowanych.

Co więcej, w rozwiązaniach webowych często potrzebujemy kodu uruchamianego na serwerze. Pomyśl np. o panelu administracyjnym restauracji – musi on pozwalać np. na edycję rezerwacji stolika, ale tylko jeśli użytkownik panelu pomyślnie się zalogował. To jest kwestia bezpieczeństwa danych, więc nie możemy opierać się na kodzie JS uruchamianym w przeglądarce, ponieważ jest on w pełni dostępny dla każdego. Z tego względu potrzebujemy skryptu na serwerze, który sprawdzi, czy użytkownik podał prawidłowe hasło – i tylko w tym przypadku pozwoli mu pobrać czy edytować informacje dotyczące rezerwacji stolika.

Czy to oznacza, że za pomocą JS-a po prostu nie stworzymy pewnych rzeczy i musimy w takich sytuacjach używać innych języków programowania? Niekoniecznie.

26.1. Czym jest Node.js?

Możemy domyślać się, że przeglądarka używa jakiegoś silnika do kompilacji JS-a i faktycznie tak jest, np. ten używany w Google Chrome nazywa się V8. Zatem pojawia się pytanie, czy istnieje możliwość, żeby dostać się do samego silnika i użyć go bez pomocy przeglądarki? Tym samym mielibyśmy możliwość bezpośredniej kompilacji plików JS, i to bez ograniczeń, które nakładają nam przeglądarki. Odpowiedź brzmi – tak, jest taka możliwość!

Dokładnie taka idea przyświecała twórcom Node.js, którzy stworzyli kompilator zbudowany właśnie na podstawie silnika V8. Pozwala on na bezpośrednie kompilowanie plików JS oraz co ważniejsze – daje możliwość dostępu do komputera, na którym uruchomiony został skrypt JS. Mowa tu o systemie plików (czyli możliwości zapisu, odczytu, modyfikacji danych), ale też informacji o samym systemie (używany język, procesor itd.).

To otwiera przed nami ogromne perspektywy. Możemy pisać za pomocą JS-a aplikacje desktopowe, które w pełni wykorzystują możliwości naszego komputera. Aplikacje edytora tekstu, eksploratory plików, programy do obróbki grafiki – to wszystko może być napisane w JS-ie dzięki Node.js. Co więcej, skoro uzyskujemy bezpośredni dostęp do komputera, mamy też możliwość uruchamiania na jego podstawie serwera! Jest to dla nas świetna informacja, ponieważ oznacza, że w JS-ie istnieje możliwość pisania również backendowych części aplikacji. I właśnie to – możliwość pisania zarówno frontendu, jak i backendu w jednym języku (JS), jest prawdopodobnie największą zaletą Node.js.

Platforma ta rozwija się niezwykle dynamicznie, przez co lawinowo rośnie liczba dodatkowych modułów i narzędzi udostępnianych w internecie, ułatwiających życie programistom. Przekonasz się o tym już wkrótce. Node.js daje nam szybki, skalowalny i międzyplatformowy sposób na tworzenie aplikacji. Jego możliwości sięgają jednak o wiele dalej, np. w znaczący sposób wspomagają rozwój Internetu Rzeczy (Internet of Things).

Żeby jeszcze bardziej zachęcić Cię do tej technologii, może warto podać też kilka przykładów użycia jej w praktyce. Z Node.js korzystano m.in. przy budowie edytorów tekstu Atom, Visual Studio Code, czy desktopowej wersji komunikatora Slack. Odpowiada on również m.in. za obsługę serwera takich "molochów" w branży jak Netflix czy Paypal. Musisz przyznać, że to robi wrażenie :)

W tym module zaczniemy naszą znajomość z Node.js, tworząc kilka rozwiązań uruchamianych (głównie) na naszym komputerze. Dzięki temu zobaczysz, jak przydatny może być JS, zarówno w tworzeniu rozwiązań desktopowych, jak i tworzeniu narzędzi pomocnych w procesie tworzenia projektów. Będzie to dobry wstęp, który pozwoli nam – w kolejnych modułach – skupić się na rozwiązaniach stricte backendowych.

To wszystko może brzmieć nieco onieśmielająco, ale pamiętaj, że to nadal jest JavaScript! Będziemy w nim po prostu korzystać z nowych bibliotek, które otworzą przed nami nowe możliwości, ale w większości będziemy po prostu dalej rozwijać nasze umiejętności w zakresie pisania JS-a – w czym mamy już trochę wprawy.

Instalacja Node.js

To chyba najłatwiejsza część niniejszego modułu. Nie musisz niczego instalować, bo masz to już za sobą. Udało Ci się tego dokonać na etapie poznawania NPM-a. Aby go używać, musieliśmy najpierw zainstalować Node.js. Warto bowiem wiedzieć, że NPM to narzędzie w zamyśle stworzone specjalnie pod Node'a. Bez niego nie jest w stanie funkcjonować. Widać to zresztą nawet w samej nazwie NPM-a, która jest skrótem od Node Package Manager.

Co więcej, samego Node'a używaliśmy również w praktyce, m.in. w naszym task runnerze. Robiliśmy to zawsze wtedy, kiedy konieczna była np. praca na plikach. Przykładem mogą być paczki autoprefixer-cli czy node-sass, które pod maską używały właśnie Node'a, aby modyfikować arkusze stylów. W dodatku, json-server, który służył nam za serwer i API, również zalicza się do tej kategorii. O ile więc nie używaliśmy jeszcze Node.js bezpośrednio, to tak naprawdę nasze aplikacje już czerpały z jego możliwości.

Jeśli nie masz pewności, czy Node.js jest zainstalowany na komputerze, z którego korzystasz w tej chwili, możesz użyć poniższej komendy:

node -v

Powinna ona nie tylko poinformować Cię, czy Node.js jest zainstalowany na Twoim komputerze, ale również wskazać Ci aktualnie używaną wersję. W niniejszym module będziemy pracować na ostatniej stabilne wersji (tzw. LTS – Long Term Support), czyli 10.16.0. Jeśli Twoja wersja jest starsza, warto ją w tym momencie zaktualizować.

Pierwsze starcie

Czas w końcu wykorzystać Node'a w praktyce. Wiemy już, jakie są jego zalety i że jest zainstalowany na naszym komputerze. Napiszmy więc pierwszą aplikację, którą uruchomimy poza środowiskiem przeglądarki!

Zacznij od stworzenia katalogu projektu, a w nim jednego pliku – index.js.

Sam plik może być bardzo prosty. Jako zawartość wpisz instrukcję pokazującą w konsoli klasyczne Hello world!.

console.log('Hello world!');

Teraz czas na wykorzystanie Node'a! Jak widzisz, w naszym folderze, nie ma żadnego innego pliku. Node.js potrafi uruchomić pliki JS bezpośrednio. Wystarczy, że otworzysz konsolę i wpiszesz komendę node nazwapliku, a więc w naszym przypadku:

node index.js

Po wykonaniu tej komendy, w konsoli powinien pokazać się następujący komunikat:

Hello world!

Brawo! Właśnie udało Ci się napisać i uruchomić pierwszą aplikację JS-ową poza przeglądarką!

26.2. Nowe możliwości

Fakt, że możemy uruchamiać aplikacje JS-owe bez pomocy przeglądarki, jest na pewno pozytywny, ale Node.js daje nam mnóstwo innych możliwości. Teraz postaramy się wykorzystać je w praktyce.

Pierwsza aplikacja

Node.js pozwala obsługiwać system plików, daje dostęp do informacji o systemie i moduł do tworzenia serwerów.

Jako przykładową aplikację zbudujemy mały program, którego zadaniem będzie pokazywanie informacji o systemie – jego platformie, architekturze i samym użytkowniku. Zaczniemy skromnie, ale będzie to nasz pierwszy skrypt, który faktycznie wykorzystuje możliwości niedostępne przy kompilacji przez przeglądarkę.

Zacznij od stworzenia katalogu projektu. Następnie utwórz w nim nowy plik – app.js (nazwa oczywiście może być dowolna).

Następnie otwórz ten plik i rozpocznij od wpisania następującej instrukcji:

const os = require('os');

W tym momencie musimy się jednak zatrzymać. O co chodzi? Co to za require i po co w ogóle ta linijka?

Kompilator Node.js w zamyśle wspiera wszystkie standardowe komendy JS-owe. Niemniej jednak te, które są dostarczane jako "dodatkowe", jak właśnie moduł os z informacjami o systemie, nie są dostępne domyślnie. Powód jest prosty – nie w każdym projekcie będą przecież potrzebne. Na przykład w aplikacji z pierwszego submodułu, ładowanie informacji o systemie, albo funkcji do obsługi plików, byłoby zbędne. Musimy zatem wskazać, że dany moduł jest wymagany i dopiero wtedy będziemy mogli z niego korzystać. Zatem require możesz odczytywać jako zwykły import. Zapis jest trochę inny, ale koncept dokładnie taki sam – chcemy załadować jakiś moduł, jakąś funkcjonalność z zewnątrz, do naszego skryptu.

Powyższą instrukcję traktujmy więc po prostu jak rozkaz: załaduj moduł os jako stałą os.

require vs import

Wiemy, że sam koncept CommonJS (tak nazywa się system formatowania z require) jest bardzo podobny do import, które już pojawiło się w kursie. Zapis jest jednak trochę inny. Warto więc nieco o tym opowiedzieć.

Eksportowanie całych modułów wygląda następująco:

module.exports = {
  id: 1,
  name: 'TestModule',
};

vs

export default {
  id: 1,
  name: 'TestModule',
};

Główną różnicą jest przede wszystkim potrzeba dodawania znaku równości, co możemy odczytywać jako przypisywanie modułu do obiektu "eksportu", który potem w innym miejscu jest importowany. Reszta jest bardzo podobna.

Import, jak widzieliśmy już wcześniej, też działa w obu przypadkach na podobnych zasadach.

const module = require('./module');

vs

import module from './module';

Eksportowanie pojedynczych funkcjonalności, które znamy z import ... export, również jest możliwe w require.

exports.helloWorld = function() {
  ...
}

exports.test = function() {
  ...
}

A samo ich importowanie wręcz identyczne:

const { helloWorld, test } = require('./module');

Jak widzisz, sam pomysł modułów, ich importowanie i eksportowanie bardzo przypomina nam to, co znamy już z import ... export. Nic w tym dziwnego. W końcu twórcy jednego i drugiego rozwiązania chcieli, w gruncie rzeczy, osiągnąć ten sam efekt – możliwość podziału kod na łatwe do importowania części.

Pojawia się jednak pytanie: dlaczego Node.js nie korzysta po prostu ze zwykłego importu?

Po pierwsze, Node.js działa w innym środowisku, a require wykonuje swoją pracę trochę inaczej niż import ... export, mimo tego, że efekt jest bardzo często wręcz identyczny. Po drugie, gdy Node.js wchodził na rynek, pomysł import ... export nie był jeszcze standardem. Gdyby tworzono Node'a od zera właśnie teraz, zapewne od razu wykorzystany zostałby właśnie ten najnowszy standard. Może działałby trochę inaczej pod maską, ale programiści używaliby go identycznie.

Nie musisz się jednak martwić. Node.js przygotowuje się na "przesiadkę". Prawdopodobnie niedługo będzie możliwe natywne wykorzystywanie import ... export, zamiast require.

Jeśli czujesz, że powyższe krótkie wprowadzenie to dla Ciebie za mało, zajrzyj do oficjalnej dokumentacji.

Eksperymentalny tryb pracy

Na jakim etapie są prace związane z "przesiadką" Node.js na nowy sposób importowania modułów? Na stosunkowo zaawansowanym – import ... export jest już dostępne w najnowszych wersjach, choć na razie jedynie w wersji eksperymentalnej.

Jeśli zainstalujemy Node'a w wersji wyższej niż 12, to aby skorzystać z takiej opcji, wystarczy uruchomić aplikacje z flagą --experimental-modules oraz zmienić rozszerzenie używanych w projekcie plików na .mjs.

Dlaczego więc my nie ułatwimy sobie pracy już teraz, skoro istnieje taka możliwość?

Po pierwsze, funkcjonalność ta jest w fazie eksperymentalnej, przez co jej stabilność może pozostawiać sporo do życzenia. Po drugie, istnieje duża szansa, że w swojej pracy natrafisz jeszcze na wykorzystanie require w starszych aplikacjach i to nawet wtedy, kiedy import ... export będzie już całkowicie wspierane w Node.js. Warto więc wiedzieć jak ten koncept działa. Zwłaszcza że dzięki podobieństwu do poznanego już przez Ciebie import ... export, jest on stosunkowo łatwy do przyswojenia.

Wracamy do pracy

Skoro wiemy już, do czego potrzebna nam pierwsza linijka kodu, możemy wracać do pracy. Zacznijmy od sprawdzenia, co tak naprawdę znajduje się w module os.

Do pliku app.js, który wcześniej utworzyliśmy, dodaj kolejną linijkę – console.log(os).

const os = require('os');

console.log(os);

Następnie skompiluj ten kod za pomocą Node'a (node app.js) i zobacz, co pojawi się w konsoli.

image

Jak widzisz, moduł os to bardzo duży obiekt. Co ciekawe jednak okazuje się, że same informacje o systemie nie są przechowywane jako zwykłe stringi lub liczby. Zauważ, że zamiast tego, moduł oferuje zestaw metod i to dopiero one będą zwracać nam odpowiednie informacje.

Skoro wiemy już, jak wygląda struktura tego modułu, możemy dostać się teraz do potrzebnych informacji.

Założyliśmy, że nasza aplikacja pokaże następujące dane: rodzaj platformy, rodzaj architektury i jakieś podstawowe informacje o użytkowniku. Jak możemy przypuszczać, pierwsza informacja powinna być zwracana prawdopodobnie przez metodę platform(), druga przez arch(), a dane o użytkowniku zapewne znajdują się w userInfo().

Zaczniemy od pokazania tych dwóch pierwszych informacji:

const os = require('os');

console.log('Platform: ', os.platform());
console.log('Arch: ', os.arch());

W ramach treningu, dodanie kodu pokazującego informacje o użytkowniku (wystarczy jego login), pozostawimy już Tobie :)

Podsumowanie

Gotowe! Oczywiście Node.js oferuje więcej nowych możliwości, ale moduł os dość dobrze pokazuje, czego możemy się po nich wszystkich spodziewać. Jak widzisz, korzystanie z nich nie jest wcale kłopotliwe. Wciąż piszemy kod w JS, z tą różnicą, że nie potrzebujemy już pomocy przeglądarki do kompilacji i nic nie odgranicza nam tak mocno dostępu do systemu i komputera użytkownika.

Zadanie: generator tożsamości

Nasza pierwsza "poważna" aplikacja nie była jeszcze zbyt rozbudowana i raczej nie pochwalilibyśmy się nią w portfolio. Teraz zabierzemy się za coś ciekawszego.

Twoim zadaniem będzie zbudowanie aplikacji generującej tablicę 20 losowych tożsamości, a następnie zapisywanie jej do pliku people.json. Cała operacja powinna odbywać się od razu przy kompilacji aplikacji, a więc samo uruchomienie komendy node app.js powinno powodować natychmiastowe wygenerowanie pliku people.json z danymi.

Do tego, po zakończeniu pracy, konsola powinna pokazywać informację o sukcesie – "File has been successfully generated! Check people.json" albo o ewentualnej porażce – "Something went wrong".

Każda tożsamość powinna być generowana jako obiekt z następującymi atrybutami:

  • gender – płeć, losujemy z dwóch opcji M i F,
  • firstName – imię, losujemy je z kilku możliwych. Przy czym warto przygotować sobie osobne tablice dla sytuacji wylosowania płci męskiej i żeńskiej,
  • lastName – nazwisko, losujemy je z tablicy, w której znajduje się kilka przygotowanych,
  • age – wiek, losujemy wartość od 18 do 78.

Do wykonania zadania koniecznie będzie wykorzystanie kolejnego wbudowanego modułu fs. Służy on właśnie do pracy nad plikami i katalogami. Skorzystaj z niego w oparciu o oficjalną dokumentację. Od razu podpowiemy jednak, że w poniższym zadaniu konieczne będzie wykorzystanie jednej metody – writeFile, która wygląda następująco:

fs.writeFile('outputfile.txt', data, (err) => {
  if (err) throw err;
  console.log('The file has been saved!');
});

Jak można zauważyć, ta metoda potrzebuje tak naprawdę tylko trzech parametrów. Pierwszy ustala, jak ma nazywać się plik, który chcemy utworzyć, drugi definiuje jego zawartość, a trzeci to funkcja "callback", która uruchamia się po zakończeniu operacji. Przy czym, jeśli pojawi się błąd, ta funkcja będzie miała do niego dostęp pod argumentem err. W powyższym przykładzie sprawdzamy więc po prostu, czy nie doszło do błędu i dopiero, jeśli nie, informujemy użytkownika o sukcesie.

Jeśli uważasz, że tyle informacji Ci wystarczy, zbierz się do pracy. Gdyby jednak okazało się, że potrzebujesz dalszego naprowadzenia, przeczytaj jeszcze podrozdział "Aplikacja krok po kroku".

Aplikacja krok po kroku

Twoja aplikacja powinna wyglądać następująco:

  1. Zacznij od zaimportowania modułu fs (jako stałej fs) oraz od przygotowania tablic genders, maleNames, femaleNames i lastNames.
  2. Następnie stwórz funkcję randChoice(), która powinna pobierać jeden argument arr. Jej zadaniem ma być zwrócenie losowego elementu z tablicy otrzymanej z arr. Możesz wykorzystać tu Math.random().
  3. Stwórz nową tablicę people. Na razie niech będzie pusta.
  4. Przygotuj pętlę, która wykona się 20 razy.
  5. Wewnątrz pętli napisz kod, którego zadaniem będzie utworzenie nowego obiektu, wylosowanie płci za pomocą randChoice i przypisanie jej jako atrybut gender. Następnie, w zależności od wylosowanej płci, wybierz za pomocą randChoice imię z odpowiedniej tablicy. Na końcu wylosuj w podobny sposób również nazwisko (już bez użycia randChoice, a za pomocą zwykłego Math.random()), oraz ustal wiek osoby. Taki gotowy obiekt (z atrybutami gender, firstName, lastName i age) powinien być dodany (push) do tablicy people.
  6. Po pętli zajmij się skonwertowaniem tablicy do formatu JSON, a następnie zapisz te dane w pliku people.json.

Jeśli po wykonaniu wszystkich kroków i uruchomieniu komendy kompilacji, wygenerował się plik people.json z losowymi tożsamościami, to znaczy, że wszystko działa poprawnie!

Szukanie błędów

Gdy aplikacja nie działa, Twoim pierwszym krokiem dotychczas było sprawdzenie przeglądarkowej konsoli. Właśnie tam otrzymywaliśmy komunikaty o błędach. Teraz takiej opcji nie mamy, bowiem nie używamy przeglądarki. Zamiast tego błędy będą się pokazywały bezpośrednio w konsoli, w której uruchomiłeś aplikację. I właśnie tam, w przypadku problemów, będziemy ich szukać.

Dla ambitnych

Jeśli zadanie było dla Ciebie mało wymagające, to spróbuj rozwinąć aplikację o losowanie dodatkowych atrybutów – numeru telefonu oraz adresu email (np. zawsze w formacie firstname.lastname@gmail.com, czyli np. john.doe@gmail.com dla wylosowanego Johna Doe).

26.3. Zewnętrzne paczki

We wcześniejszym submodule korzystaliśmy z dwóch modułów wbudowanych w Node.js. Podczas kursu wielokrotnie wykorzystywaliśmy też różne paczki z zewnątrz. Czy w przypadku Node'a również jest to możliwe? Jak najbardziej! W końcu nawet sam NPM powstał właśnie w tym celu – aby ułatwić pobieranie zewnętrznych paczek/modułów do projektów node'owych.

W tym submodule postaramy się skorzystać z tej możliwości i aby było ciekawiej, zrobimy to nie "na sucho", lecz ponownie pisząc małą aplikację. Przy czym znów postaramy się zawrzeć w niej właśnie takie funkcjonalności, których przy kompilacji w przeglądarce nie bylibyśmy w stanie wykorzystać.

Co zbudujemy?

Aplikacją, którą zajmiemy się w tym submodule będzie program dodający watermarki (znaki wodne) do zdjęć. Pomysł jest następujący:

  1. Gdy uruchomimy aplikację, spyta nas ona o ścieżkę do pliku, który ma zostać oznakowany watermarkiem.
  2. Jeżeli taki plik istnieje, zostaniemy spytani o rodzaj watermarka – mamy do wyboru text albo image (watermark tekstowy albo obrazkowy – np. logo).
  3. Następnie użytkownik musi jeszcze wpisać tekst znaku wodnego albo wskazać ścieżkę do pliku graficznego (jeśli wybrał opcję image).
  4. Na końcu, aplikacja ma w założeniu tworzyć nowy plik graficzny, oparty na tym, który wskazano na starcie, ale już z dodanym watermarkiem i zapisać go do nowego pliku.

Oczywiście nie będziemy musieli wszystkiego robić od zera. Wspomożemy się paczkami zewnętrznymi:

  • Jimp – to paczka do obsługi plików graficznych. Pozwala na wiele różnorakich operacji, takich jak zmiana rozmiaru obrazka, jego jakości, jasności, kolorów, oraz na dodawanie tekstu, a także łączenie obrazków.
  • Inquirer – pozwala na łatwe zadawanie pytań w konsoli, co usprawni naszą komunikację z użytkownikiem.

Zaczynamy!

Zaczniemy od prostych rzeczy, a w kolejnych krokach będziemy rozbudowywać naszą aplikację tak, by osiągnąć na końcu pożądany efekt. Rozpoczniemy od przygotowania kluczowych funkcjonalności – funkcji addTextWatermarkToImage, służącej do dodawania watermarka tekstowego na obrazek, a także addImageWatermarkToImage, do dodawania watermarka obrazkowego (np. loga).

Zacznijmy od stworzenia nowego folderu projektu oraz wygenerowania package.json.

yarn init

Następnie pobierzemy pierwszą paczkę:

yarn add jimp@0.6.4

W kolejnym kroku stwórz plik app.js. Będzie to główny i jak na razie jedyny plik naszej aplikacji.

Następnie dodamy nasz pierwszy kod – funkcję addTextWatermarkToImage:

const Jimp = require('jimp');

const addTextWatermarkToImage = async function(inputFile, outputFile, text) {
  const image = await Jimp.read(inputFile);
  const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);
  image.print(font, 10, 10, text);
  await image.quality(100).writeAsync(outputFile);
};

addTextWatermarkToImage('./test.jpg', './test-with-watermark.jpg', 'Hello world')

Na ten moment, aplikacja po uruchomieniu powinna znaleźć w folderze projektu plik test.jpg, a następnie dokleić do niego tekst Hello world i zapisać nową wersję obrazka jako test-with-watermark.jpg. Jeśli chcesz przetestować, czy faktycznie tak się dzieje, dodaj do folderu projektu przykładową grafikę w formacie .jpg o nazwie test.jpg. Następnie uruchom komendę node app.js. Nowy plik powinien zostać wygenerowany jako test-with-watermark.jpg, a w jego lewym górnym rogu powinien znajdować się tekst Hello world!.

image

Zanim przejdziemy do omawiania kodu, musimy jeszcze ustalić jedną rzecz. Dokumentacja Jimpa informuje, że możemy wykorzystywać jego funkcjonalności w formie callbacków albo promise'ów. My wybierzemy tę drugą opcję, jednak aby poprawić czytelność kodu, zastosujemy do tego jeszcze async ... await (tak jak w powyższym przykładzie). Zwracamy na to uwagę, bowiem w przykładach z dokumentacji, nawet przy użyciu kilku funkcjonalności naraz, zastosowany został zwykły "chaining", co powoduje, że kod może być mało czytelny.

A teraz po kolei omówmy sobie, co już przygotowaliśmy.

const Jimp = require('jimp');

Powyższy kod nie wymaga wyjaśnień. Po prostu importujemy pobrany moduł do naszej aplikacji, podobnie jak robiliśmy to już wielokrotnie z import.

Wewnątrz funkcji addTextWatermarkToImage pojawia się jednak trochę więcej nowości wymagających wyjaśnień.

const image = await Jimp.read(inputFile);

Metoda .read modułu Jimp służy do ładowania plików graficznych. Jimp wspiera następujące formaty: jpeg, png, bmp, tiff, gif. Po załadowaniu i przypisaniu pliku do zmiennej (u nas jest nią image), mamy do niego dostęp w dalszej części kodu. await gwarantuje nam oczywiście, że kompilacja nie pójdzie do przodu, dopóki ten plik nie zostanie załadowany.

const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);

Aby napisać tekst, potrzebna będzie nam jakaś czcionka. Jimp zezwala na załadowanie własnych fontów, ale oferuje również dostęp do jednego podstawowego, bez potrzeby pobierania. Jest to Open Sans, dostępny wraz z samą paczką. Jimp.FONT_SANS_32_BLACK to konkretnie odwołanie do czcionki Open Sans o rozmiarze 32px i kolorze black (czarnym). Możemy wybrać jednak analogicznie inne rozmiary oraz inny kolor – biały. Dokładny opis jak korzystać z tej opcji znajdziesz w dokumentacji.

Funkcja loadFont po prostu ładuje font i przypisuje go do zmiennej, aby dało się go potem wykorzystać przy próbie "pisania" tekstu na obrazku.

image.print(font, 10, 10, text);

W tym miejscu już fizycznie dodajemy napis na obrazku. Pierwszy parametr metody print ustala, z jakiej czcionki skorzystamy, drugi i trzeci decydują o umiejscowieniu tekstu na obrazku w poziomie i pionie. Czwarty naturalnie decyduje już o samej treści tekstu.

await image.quality(100).writeAsync(outputFile);

Ostatnia linijka jest stosunkowo intuicyjna. Zapisujemy zmieniony obrazek jako nowy plik, a robimy to przy użyciu writeAsync. Dodatkowo użyta metoda quality pozwala na ustalenie jakości, w jakiej zapiszemy plik. W naszym przypadku nie chcemy "psuć" wyglądu obrazka, więc wybraliśmy maksymalnie wysoką jakość (100%). Gdybyśmy jednak budowali np. aplikację do kompresowania zdjęć, byłaby to bardzo przydatna opcja.

Przed nami jeszcze wiele pracy, ale mamy już podwaliny jednej z głównych funkcjonalności.

Rozwijamy addTextWatermarkToImage

Nasz addTextWatermarkToImage działa już całkiem sprawnie. Warto jednak minimalnie zmodyfikować kod. Na razie tekst pokazuje się w lewym górnym rogu ekranu. Przestawmy go na środek, tam, gdzie najczęściej widuje się watermarki.

Jak to możemy to zrobić, pokazuje sama dokumentacja. Wystarczy, że potraktujemy tekst jako obiekt z trzema parametrami:

{
  text: text,
  alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
  alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
},

Wciąż będziemy decydować jaki tekst pokażemy, ale przy okazji możemy również ustalić, jakie powinno być jego wyrównanie.

Powyższe atrybuty powinny ustawić tekst na środku i to w poziomie, jak i w pionie. W samej dokumentacji są pokazane także opcje, które pozwalają wyrównywać tekst do góry, do dołu itd.

Żeby jednak działanie tej opcji było możliwe, musimy jeszcze poinformować Jimpa o szerokości i wysokości obrazka. Jest to możliwe przy użyciu czwartego i piątego parametru metody print. Z racji tego, że nie wiemy jaki obrazek i o jakim rozmiarze użytkownik będzie chciał "oznakować", nie możemy podać sztywnych wartości, lecz musimy ustalić po prostu fizyczny rozmiar obrazka. Pomogą nam w tym dwie wbudowane w Jimpa funkcje: getWidth i getHeight.

Po zmianach użycie metody print powinno wyglądać tak:

const textData = {
  text,
  alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
  alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
};
image.print(font, 0, 0, textData, image.getWidth(), image.getHeight());

A cała funkcja tak:

const addTextWatermarkToImage = async function(inputFile, outputFile, text) {
  const image = await Jimp.read(inputFile);
  const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);
  const textData = {
    text,
    alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
    alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
  };

  image.print(font, 0, 0, textData, image.getWidth(), image.getHeight());
  await image.quality(100).writeAsync(outputFile);
};

Od tej chwili watermark tekstowy będzie zawsze pojawiał się na środku obrazka.

image

Watermark obrazkowy

Nasza aplikacja powinna pozwalać również na dodawanie watermarka w formie obrazka. Zajmiemy się tym właśnie teraz!

Dopisz do app.js kolejną część kodu:

const addImageWatermarkToImage = async function(inputFile, outputFile, watermarkFile) {
  const image = await Jimp.read(inputFile);
  const watermark = await Jimp.read(watermarkFile);

  image.composite(watermark, 0, 0, {
    mode: Jimp.BLEND_SOURCE_OVER,
    opacitySource: 0.5,
  });
  await image.quality(100).writeAsync(outputFile);
};

addImageWatermarkToImage('./test.jpg', './test-with-watermark2.jpg', './logo.png');

Sama funkcja, podobnie jak pierwsza, przyjmuje trzy parametry. Z tą różnicą, że teraz jako trzeci będzie otrzymywać ścieżkę do pliku znaku wodnego.

const image = await Jimp.read(inputFile);
const watermark = await Jimp.read(watermarkFile);

Początek jest dość oczywisty. Ładujemy obrazek źródłowy oraz grafikę znaku wodnego.

Nas interesuje przede wszystkim poniższa część:

image.composite(watermark, 0, 0, {
  mode: Jimp.BLEND_SOURCE_OVER,
  opacitySource: 0.5,
});

Metoda composite służy do łączenia dwóch obrazków ze sobą. Przykleja ona do pliku wejściowego nowy obrazek. To właśnie coś, czego potrzebowaliśmy. Przy czym, z pomocą opcji mode, można wybrać, w jaki sposób to połączenie zostanie przeprowadzone. Czy obrazek źródłowy powinien być na wierzchu? A może na odwrót? U nas oczywiście wybraliśmy opcję pokazywania drugiego obrazka (naszego watermarka) na wierzchu – nad obrazkiem źródłowym.

Oprócz tego ustawiamy w niej również pozycję, jaką nowy obrazek ma przyjąć na grafice źródłowej (u nas to 0 w poziomie i 0 w pionie). Do tego dodaliśmy również opcję opacitySource, która powoduje, że nasz watermark jest lekko przezroczysty. To dość standardowy pomysł w przypadku znaków wodnych.

await image.quality(100).writeAsync(outputFile);

Powyższa część kodu nie jest już dla Ciebie nowością.

Aby przetestować działanie nowej funkcji, dodaj do swojego katalogu nowy plik (logo.png). Jeśli nie chcesz tracić czasu na szukanie czegoś pasującego, możesz do testów pobrać logo Kodilli i właśnie jego użyć.

Aplikacja po uruchomieniu powinna generować teraz dwa pliki ./test-with-watermark.jpg oraz ./test-with-watermark2.jpg. Nas interesuje ten drugi, w którym powinniśmy zobaczyć nasz watermark graficzny.

image

Wyrównujemy logo do środka

Podobnie jak przy watermarku tekstowym, tutaj również dobrze byłoby wyrównać tekst do środka. Niestety tym razem nie mamy możliwości użycia gotowych ustawień. Zamiast tego musimy sami wyliczyć, jaka pozycja w poziomie i pionie, da nam pożądane umiejscowienie.

const x = image.getWidth() / 2 - watermark.getWidth() / 2;
const y = image.getHeight() / 2 - watermark.getHeight() / 2;

Pomysł jest dość prosty. Szukamy środka głównego obrazka, a potem jeszcze odejmujemy od znalezionych wartości połowę rozmiaru watermarka. Jest to pomysł identyczny z używanym przez nas na początku kursu, jeszcze w CSS, czyli position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%).

Ostatecznie nasza funkcja powinna wyglądać następująco:

const addImageWatermarkToImage = async function(inputFile, outputFile, watermarkFile) {
  const image = await Jimp.read(inputFile);
  const watermark = await Jimp.read(watermarkFile);
  const x = image.getWidth() / 2 - watermark.getWidth() / 2;
  const y = image.getHeight() / 2 - watermark.getHeight() / 2;

  image.composite(watermark, x, y, {
    mode: Jimp.BLEND_SOURCE_OVER,
    opacitySource: 0.5,
  });
  await image.quality(100).writeAsync(outputFile);
};

Efektem powyższych zmian, będzie pozycjonowanie watermarka graficznego zawsze pośrodku obrazka bazowego.

image

Brawo! Nasze dwie główne funkcjonalności są już gotowe!

Komunikacja z użytkownikiem

Mamy już dwie funkcje, które są kluczowe dla naszej aplikacji. Na razie uruchamiają się one jednak zawsze z tymi samymi sztywnymi danymi. Zamysł był inny. To użytkownik powinien decydować, na jakim pliku pracujemy, oraz jak ma wyglądać nasz watermark. Tym, a więc komunikacją z użytkownikiem, zajmiemy się właśnie teraz.

Gdzie jest prompt?

Od razu jednak pojawia się pewien problem. Oczywiście, radziliśmy już sobie z komunikacją z użytkownikiem bez użycia HTML-a. Było to na początku kursu, kiedy dopiero poznawaliśmy podstawy tego języka. Do "pytania" użytkownika wykorzystywaliśmy metodę prompt, dostępną w przeglądarkowym obiekcie window. Problem w przypadku Node'a jest jednak taki, że jeśli nie ma przeglądarki, to nie ma też obiektu window. Jeśli nie ma obiektu window, to naturalnie nie ma też metody prompt. W jakiś sposób Node.js musi jednak pozwalać nam na komunikację z użytkownikiem, prawda? W końcu to dość standardowa funkcjonalność.

Obiekt process

I faktycznie, Node.js nie zostawia nas bez pomocy. Nie mamy tutaj obiektu window (w końcu nie ma tu żadnego okna), ale istnieje obiekt process. Zawiera on mnóstwo przydatnych funkcjonalności, z czego nas interesuje zwłaszcza jedna – możliwość komunikacji z użytkownikiem za pomocą strumieni wymiany danych (stdin, stdout, stderr).

Uwaga!

Obiekt process może być od razu używany w każdym pliku. Nie musimy tutaj niczego specjalnie importować.

Poniżej możesz zobaczyć przykład aplikacji zbudowanej z pomocą tej funkcjonalności:

process.stdout.write('Type "E" to exit, type "H" to say hello!');

process.stdin.on('readable', () => {

  const input = process.stdin.read();

  const instruction = input.toString().trim();
  if (instruction === 'E') {
    process.stdout.write('Exiting app...');
    process.exit();
  }
  else if (instruction === 'H') {
    process.stdout.write('Hi! How are you?');;
  }
  else {
    process.stdout.write('Wrong instruction!\n');
  }

});

Omówmy sobie krótko ten kod.

process.stdout.write('Type "E" to exit, type "H" to say hello!');

Funkcja process.stdout.write po prostu wypisuje tekst w konsoli. console.log, którego używaliśmy wcześniej, "pod maską" korzystało właśnie z niej.

process.stdin.on('readable', () => {

Powyższy kod ciągle nasłuchuje działań użytkownika. Kiedy wykryje, że coś wpisano i kliknięto enter, uruchomi wybraną funkcję.

const input = process.stdin.read();

W tym miejscu "dobieramy" się do tej wpisanej informacji za pomocą metody read na strumieniu wejścia.

const instruction = input.toString().trim();

Dalej "trimujemy" jeszcze wpisaną wartość z niepotrzebnych znaków.

Reszta kodu powinna być dla Ciebie zrozumiała. Zależnie od wpisanej litery, wykonamy odpowiednią operację. Nowością jest jeszcze tylko process.exit();, ale pewnie udało Ci się już domyślić, że służy po prostu zakończeniu procesu. Działa tak samo, jak np. kombinacja Ctrl+C w Git Bashu.

Jak widzisz, mimo braku prompt, jesteśmy w stanie sobie poradzić. Powyższy kod nie wygląda jednak zbyt zachęcająco. Aby ułatwić sobie pracę, skorzystamy z kolejnej zewnętrznej paczki, o której była mowa na początku submodułu – Inquirer.

A co ze zmiennymi globalnymi?

Powiedzieliśmy, że w Node.js nie istnieje obiekt window i z tego powodu nie mamy dostępu do metody prompt. Jednak window to więcej niż tylko jedna metoda. W takiej sytuacji pojawia się ważne pytanie. Wiemy, że gdy dodajemy w kodzie JS zmienne globalne, są one przypisywane właśnie do obiektu window. Zatem jak to wygląda w Node.js?

Podobnie jak z process, tu też istnieje specjalny obiekt – o nazwie... global. Oczywiście, dostępny w każdym pliku.

Wprowadzamy Inquirera

Zacznijmy od zainstalowanie tej paczki:

yarn add inquirer@6.5.0

Następnie, aby ją użyć, musimy dokonać importu w naszym app.js.

const inquirer = require('inquirer');

Jak używa się tej paczki? Stosunkowo prosto, chociaż na początku mnogość możliwości, którą otrzymujemy, może być przytłaczająca.

Spójrz na pierwszy przykład z dokumentacji:

inquirer
  .prompt([
    /* Pass your questions in here */
  ])
  .then(answers => {
    // Use user feedback for... whatever!!
  });

Oraz na przykładowe wykorzystanie w praktyce:

inquirer.prompt([{
  name: 'name',
  type: 'input',
  message: 'What\'s your name?',
}, {
  name: 'age',
  type: 'number',
  message: 'How old are you?',
  default: 18,
}]).then((answers) => {
  console.log(`\nHi ${answers.name}. ${answers.age}? Nice! \n`);
});

Prawdopodobnie jesteś w stanie domyślić się, jak działa powyższy kod. W tym przykładzie aplikacja zapyta użytkownika o imię i wiek, a następnie wypisze komunikat tekstowy. Tablica w prompt to po prostu "lista pytań", a funkcja w .then (mamy tu zwykły promise) odpowiada za to, co ma się stać, gdy użytkownik już na nie odpowie.

image

Jeśli było Ci dane widzieć już kiedyś aplikacje CLI zbudowane w podobny sposób, pytające użytkownika o jakieś dane – bardzo możliwe, że korzystały one właśnie z Inquirera.

Warto wiedzieć, że oprócz takich prostych typów pytań jak input czy number, istnieje również wiele innych, np. list, która pozwala w łatwy sposób wybrać jedną z kilku opcji. Inquirer to potężna paczka, a my będziemy w niniejszej aplikacji korzystali tylko z części z nich. Niemniej jednak nie ma się czego obawiać. Paczka cechuje się też bardzo udaną dokumentacją.

Bierzemy się do pracy

Zacznijmy od ponownego przypomnienia, jak miała działać nasza aplikacja. Powinniśmy pytać użytkownika o kilka rzeczy:

  1. Plik wejściowy – ścieżka do pliku, na którym chcemy pracować.
  2. Rodzaj watermarka – do wyboru tekstowy albo obrazkowy (np. logo).
  3. Ścieżka do znaku wodnego lub tekst – zależnie od wyboru rodzaju watermarka.

Uwaga!

W tym miejscu ustalmy też sobie jeszcze jedną rzecz – aplikacja będzie wymagać, aby wszystkie zdjęcia znajdowały się już w folderze naszej aplikacji, a dokładnie, aby były przechowywane w folderze img. To ułatwienie dla nas. Użytkownicy mogliby mieć ogromny problem z podaniem dobrych ścieżek do plików z odległych katalogów.

Przejdźmy już teraz do komunikacji z użytkownikiem. Zacznijmy od dwóch pierwszych pytań. Dopiero po nich będziemy w stanie stwierdzić, o co trzeba spytać później – o tekst, czy może o ścieżkę do pliku graficznego znaku wodnego.

Usuń wywołania funkcji addImageWatermarkToImage oraz addTextWatermarkToImage (nie musimy już ich uruchamiać od razu, na sztywno), a następnie dodaj nową funkcję – startApp, którą będziemy uruchamiać na samym starcie.

const startApp = async () => {

  // Ask if user is ready
  const answer = await inquirer.prompt([{
      name: 'start',
      message: 'Hi! Welcome to "Watermark manager". Copy your image files to `/img` folder. Then you\'ll be able to use them in the app. Are you ready?',
      type: 'confirm'
    }]);

  // if answer is no, just quit the app
  if(!answer.start) process.exit();

  // ask about input file and watermark type
  const options = await inquirer.prompt([{
    name: 'inputImage',
    type: 'input',
    message: 'What file do you want to mark?',
    default: 'test.jpg',
  }, {
    name: 'watermarkType',
    type: 'list',
    choices: ['Text watermark', 'Image watermark'],
  }]);

}

startApp();

Czy jest tu coś nowego? Użyliśmy przede wszystkim dwóch nowych typów pytań – list i confirm. Pierwszy każe użytkownikowi wybierać z listy kilku wyborów (u nas dwóch), a drugi służy do potwierdzania (tak czy nie?).

Dodatkiem jest też na pewno zastosowanie async ... await. Z racji tego, że w naszym kodzie kilka razy będziemy zadawać kolejne pytania dopiero po wykonaniu jakichś operacji, będzie to spore ułatwienie. Inaczej skończylibyśmy z mało czytelnym kodem, w którym jedna funkcja z .then uruchamiałby kolejny promise z nowym .then, a ta ponownie odpalałby promise z jeszcze jednym .then...

Uwaga!

Inquirer zawsze zwraca jako odpowiedź obiekt z atrybutami, nawet jeśli zadajemy tylko jedno pytanie.

Dlatego też w poniższym kodzie...

// Ask if user is ready
const answer = await inquirer.prompt([{
    name: 'start',
    message: 'Hi! Welcome to "Watermark manager". Copy your image files to `/img` folder. Then you\'ll be able to use them in the app. Are you ready?',
    type: 'confirm'
  }]);

// if answer is no, just quit the app
if(!answer.start) process.exit();

...mimo zadania tylko jednego pytania i tak musieliśmy odwoływać się do jego odpowiedzi za pomocą klucza (atrybutu name pytania), w następujący sposób – answer.start.

Podobnie postępujemy również w przypadku pytań w dalszej części aplikacji.

Powyższy kod po uruchomieniu powinien zapytać użytkownika, czy chce rozpocząć cały proces. Jeśli wybierze opcję "nie", to aplikacja po prostu się wyłączy. Jeśli jednak wybierze "tak", zostanie zapytany o nazwę pliku bazowego, oraz o typ watermarka, którego chce użyć.

image image

Teraz, zależnie od typu, musimy zapytać użytkownika o tekst znaku wodnego, albo o ścieżkę do pliku graficznego.

if(options.watermarkType === 'Text watermark') {
  const text = await inquirer.prompt([{
    name: 'value',
    type: 'input',
    message: 'Type your watermark text:',
  }]);
  options.watermarkText = text.value;
}
else {
  const image = await inquirer.prompt([{
    name: 'filename',
    type: 'input',
    message: 'Type your watermark name:',
    default: 'logo.png',
  }]);
  options.watermarkImage = image.filename;
}

Jak widzisz, znowu – mimo tego, że pytamy tylko o jedną rzecz, to i tak dostajemy obiekt. Aby dojść więc do samej wartości, musimy jeszcze wybrać odpowiedni atrybut (u nas to text.value, lub image.filename).

image

Pozostała nam już tylko jedna rzecz – dodać na końcu uruchomianie funkcji addImageWatermarkToImage albo addTextWatermarkToImage.

if(options.watermarkType === 'Text watermark') {
   const text = await inquirer.prompt([{
     name: 'value',
     type: 'input',
     message: 'Type your watermark text:',
   }]);
   options.watermarkText = text.value;
   addTextWatermarkToImage('./img/' + options.inputImage, './test-with-watermark.jpg', options.watermarkText);
 }
 else {
   const image = await inquirer.prompt([{
     name: 'filename',
     type: 'input',
     message: 'Type your watermark name:',
     default: 'logo.png',
   }]);
   options.watermarkImage = image.filename;
   addImageWatermarkToImage('./img/' + options.inputImage, './test-with-watermark.jpg', './img/' + options.watermarkImage);
 }

Skąd ./img przed ścieżką do pliku? Napisaliśmy w założeniach, że oczekujemy od użytkownika, iż umieści on obrazki do edycji w folderze img. Tymczasem zadając pytanie w formularzu, oczekujemy tylko na podanie samej nazwy pliku. Musimy więc zadbać o to, by ostatecznie szukać go w odpowiednim katalogu.

Nazwa pliku końcowego

Jest jeszcze jedna kwestia. W tej chwili nasz plik końcowy nazywa się zawsze tak samo – ./test-with-watermark.jpg. Po pierwsze, lepiej, żeby nazwa opierała się na pliku źródłowym, a po drugie, nie możemy zakładać z góry, że rozszerzeniem pliku zawsze będzie akurat .jpg. Napiszemy więc teraz funkcję, która będzie zajmowała się przygotowaniem odpowiedniej nazwy pliku, a następnie wykorzystamy ją w aplikacji.

Ćwiczenie

A może spróbujesz zrobić to bez naszej pomocy? ;) Funkcja powinna nazywać się prepareOutputFilename i przyjmować jeden parametr. W założeniu będzie to nazwa pliku wraz z rozszerzeniem, więc pisząc funkcję, załóż, że zawsze otrzymasz coś takiego – test.png, abc.jpg, a więc tekst w formacie nazwa.rozszerzenie.

Funkcja powinna wykorzystać metodę split, która rozdzieli tekst na dwie części. Następnie musisz dodać do pierwszej części końcówkę -with-watermark, a następnie znowu złączyć oba fragmenty w całość. Taki gotowy string powinien być przez funkcję zwrócony.

Przykładowe działanie:

prepareOutputFilename('test.png'); //returns test-with-watermark.png
prepareOutputFilename('abc.jpg'); //returns abc-with-watermark.jpg

Jeśli nie czujesz się jednak na siłach, to poniżej znajduje się gotowa funkcja:

const prepareOutputFilename = (filename) => {
  const [ name, ext ] = filename.split('.');
  return `${name}-with-watermark.${ext}`;
};

Teraz wystarczy wykorzystać ją w naszej aplikacji:

if(options.watermarkType === 'Text watermark') {
  const text = await inquirer.prompt([{
    name: 'value',
    type: 'input',
    message: 'Type your watermark text:',
  }]);
  options.watermarkText = text.value;
  addTextWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), options.watermarkText);
}
else {
  const image = await inquirer.prompt([{
    name: 'filename',
    type: 'input',
    message: 'Type your watermark name:',
    default: 'logo.png',
  }])
  options.watermarkImage = image.filename;
  addImageWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), './img/' + options.watermarkImage);
}

Testujemy aplikację

Ostatecznie nasza aplikacja powinna wyglądać następująco:

const Jimp = require('jimp');
const inquirer = require('inquirer');

const addTextWatermarkToImage = async function(inputFile, outputFile, text) {
  const image = await Jimp.read(inputFile);
  const font = await Jimp.loadFont(Jimp.FONT_SANS_32_BLACK);
  const textData = {
    text: text,
    alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
    alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
  };

  image.print(font, 0, 0, textData, image.getWidth(), image.getHeight());
  await image.quality(100).writeAsync(outputFile);
};

const addImageWatermarkToImage = async function(inputFile, outputFile, watermarkFile) {
  const image = await Jimp.read(inputFile);
  const watermark = await Jimp.read(watermarkFile);
  const x = image.getWidth() / 2 - watermark.getWidth() / 2;
  const y = image.getHeight() / 2 - watermark.getHeight() / 2;

  image.composite(watermark, x, y, {
    mode: Jimp.BLEND_SOURCE_OVER,
    opacitySource: 0.5,
  });
  await image.quality(100).writeAsync(outputFile);
};

const prepareOutputFilename = (filename) => {
  const [ name, ext ] = filename.split('.');
  return `${name}-with-watermark.${ext}`;
};

const startApp = async () => {

  // Ask if user is ready
  const answer = await inquirer.prompt([{
      name: 'start',
      message: 'Hi! Welcome to "Watermark manager". Copy your image files to `/img` folder. Then you\'ll be able to use them in the app. Are you ready?',
      type: 'confirm'
    }]);

  // if answer is no, just quit the app
  if(!answer.start) process.exit();

  // ask about input file and watermark type
  const options = await inquirer.prompt([{
    name: 'inputImage',
    type: 'input',
    message: 'What file do you want to mark?',
    default: 'test.jpg',
  }, {
    name: 'watermarkType',
    type: 'list',
    choices: ['Text watermark', 'Image watermark'],
  }]);

  if(options.watermarkType === 'Text watermark') {
    const text = await inquirer.prompt([{
      name: 'value',
      type: 'input',
      message: 'Type your watermark text:',
    }])
    options.watermarkText = text.value;
    addTextWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), options.watermarkText);
  }
  else {
    const image = await inquirer.prompt([{
      name: 'filename',
      type: 'input',
      message: 'Type your watermark name:',
      default: 'logo.png',
    }])
    options.watermarkImage = image.filename;
    addImageWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), './img/' + options.watermarkImage);
  }

};

startApp();

Spróbuj przetestować jej działanie. Stwórz w katalogu projektu folder img i wrzuć tam dowolny obrazek o dużym rozmiarze. Dorzuć również wcześniej wspomniane logo Kodilli. Następnie uruchom aplikację i sprawdź, czy poradzi sobie z dodaniem watermarka tekstowego oraz obrazkowego.

Podsumowanie

Kod aplikacji nie jest zbyt długi, a mimo to oferuje ona całkiem ciekawą funkcjonalność, i co ważne, taką, której nie osiągnęlibyśmy w JS-ie z pomocą przeglądarki. Jak widać, Node.js daje nam spore możliwości, ale ważnym wnioskiem, który musisz wyciągnąć z tego submodułu i zadania jest fakt, że Node.js to nieco inne środowisko niż przeglądarka. Mimo że większość znanych Ci wbudowanych funkcji działa w jednym i drugim miejscu, są jednak takie elementy, które są dla nich indywidualne. Pokazaliśmy Ci to na przykładzie window (tylko w przeglądarce), czy process (tylko w Node.js). Oczywiście podczas dalszej pracy z Node.js tych różnic będzie można wychwycić jeszcze więcej. Miej to na uwadze.

Zadanie: rozwijamy aplikację

Teraz twoja kolej. Tym razem nie będziesz budować aplikacji od nowa. Twoje zadanie to rozwinięcie tego, co już w tym submodule zrobiliśmy.

Czy plik w ogóle istnieje?

Zaczniemy od jednej bardzo istotnej rzeczy – dodania walidacji, czy dany plik w ogóle istnieje. W tej chwili bowiem, jeśli ktoś nawet przypadkiem pomyli się w nazwie pliku, otrzyma bardzo nieprzyjemny komunikat o błędzie:

image

To, że jakiś komunikat się pojawia, oczywiście nie jest złe, ale raczej chcielibyśmy, aby był on przyjemniejszy dla oka. Jeśli sami chcemy decydować o jego treści, musimy zwyczajnie sprawdzać, czy dany plik w ogóle istnieje. Wtedy, w sytuacji problemu, będziemy mogli pokazywać swój własny komunikat, oraz co ważne – blokować dalsze wykonywanie funkcji, która wyrzucała nam brzydki błąd systemowy.

Czy musimy tworzyć do tego nową funkcję? Okazuje się, że jest to już częścią modułu fs i mamy do tego gotową metodę.

Sprawdź w dokumentacji funkcję existsSync, a następnie spróbuj zastosować ją w poniższym kodzie:

if(options.watermarkType === 'Text watermark') {
  const text = await inquirer.prompt([{
    name: 'value',
    type: 'input',
    message: 'Type your watermark text:',
  }])
  options.watermarkText = text.value;
  addTextWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), options.watermarkText);
}
else {
  const image = await inquirer.prompt([{
    name: 'filename',
    type: 'input',
    message: 'Type your watermark name:',
    default: 'logo.png',
  }])
  options.watermarkImage = image.filename;
  addImageWatermarkToImage('./img/' + options.inputImage, './img/' + prepareOutputFilename(options.inputImage), './img/' + options.watermarkImage);
}

Zrób to w taki sposób, aby:

  1. addTextWatermarkToImage uruchamiała się tylko wtedy, jeśli plik źródłowy istnieje.
  2. addImageWatermarkToImage uruchamiała się tylko wtedy, jeśli plik źródłowy oraz plik watermarku istnieje.

W przypadku problemu z ładowaniem, w konsoli powinien pojawiać się komunikat Something went wrong... Try again.

Uwaga!

Pamiętaj, że Node.js domyślnie nie ładuje modułu fs! Musisz go zaimportować ręcznie za pomocą require.

Komunikat o sukcesie

Kolejne zadanie jest o wiele prostsze. Dopisz na koniec funkcji addTextWatermarkToImage oraz addImageWatermarkToImage dwie instrukcje:

  1. Za pomocą console.log informującą o sukcesie.
  2. Uruchamiającą startApp od początku.

Skąd wiemy, że na pewno koniec funkcji oznacza, że wszystko poszło dobrze? Ponieważ w async ... await, gdy któryś z promise'ów po drodze się nie wykona (reject), to JS wyrzuci nam po prostu błąd i funkcja nie będzie dalej wykonywana. Mamy więc pewność, że jeśli funkcja wykonuje nasze końcowe operacje, to na pewno wcześniej nie doszło do żadnego błędu.

Try ... catch

Warto byłoby zrobić jeszcze jedną rzecz. Jeśli wiemy, że istnieje możliwość wyrzucenia przez JS-a błędu, to powinniśmy przygotować się na jego "złapanie". W przypadku użycia promise'ów bez async ... await moglibyśmy wykorzystać po prostu blok .catch(). W przypadku async ... await musimy wykorzystać blok try ... catch. Zrobimy to w następujący sposób:

try {
  // normal code, it may throw an error at some point
}
catch(error) {
  console.log(error); // if there's an error, we catch it and show it in console
}

Twoim zadaniem jest implementacja tego pomysłu w funkcjach addTextWatermarkToImage i addImageWatermarkToImage, w taki sposób, aby w przypadku błędu "wyłapywać" go, ale nie pokazywać go bezpośrednio w konsoli. Zamiast tego w razie problemów powinien pojawić się tylko komunikat: Something went wrong... Try again!.

Dla ambitnych

Jeśli to dla Ciebie za mało, spróbuj dodać do aplikacji jeszcze jedną funkcjonalność.

Aplikacja, po otrzymaniu odpowiedzi na temat ścieżki do pliku, powinna pytać o to, czy użytkownik ma ochotę go jeszcze edytować. Jeśli odpowie n, wtedy kontynuujemy bez zmian. Jeśli jednak odpowie y, to aplikacja powinna go zapytać, w formie listy, o rodzaj modyfikacji.

Do wyboru użytkownik ma mieć każdy z poniższych:

  • make image brighter
  • increase contrast
  • make image b&w
  • invert image

Wybranie każdej z operacji powinno zmienić obrazek zgodnie z opisem. Oczywiście należy wykorzystać gotowe wbudowane metody, zgodnie z dokumentacją Jimpa. Po zmianach na obrazku aplikacja powinna kontynuować działanie i zadawać kolejne pytania – o typ watermarka itd.

26.4. Electron – budujemy pełnoprawne aplikacje desktopowe

Nasza aplikacja z poprzedniego modułu pokazała, że w Node.js drzemią potężne możliwości. Mimo wszystko jednak, pod względem wyglądu, nie mieliśmy specjalnie czym się pochwalić. Cały interfejs użytkownika polegał jedynie na prostej komunikacji przy użyciu konsoli. To nie jest coś, czego oczekiwalibyśmy po nowoczesnej aplikacji desktopowej. Byliśmy jednak do tego zmuszeni. Taki był koszt odejścia od pomocy przeglądarki. Dało nam to większą swobodę przy pisaniu kodu JS, ale przy tym zabrało możliwość łatwego rozbudowywania aplikacji o przystępny wygląd (HTML i CSS).

Wspominaliśmy jednak, że przy wykorzystaniu Node'a powstały chociażby dwa rozbudowane edytory kodu (Atom.js i VSCode), gdzie przecież graficzny interfejs występuje. Jak twórcy zdołali tego dokonać? Czy my również jesteśmy w stanie budować takie aplikacje?

Sam Node.js wspiera nas tylko i wyłącznie przy kompilacji kodu JS. Nie potrafi więc renderować HTML. Istnieją jednak dodatkowe narzędzia, które w tym pomogą.

Na początku modułu powiedzieliśmy, że Google Chrome używa do kompilacji JSa silnika V8 i tak naprawdę cały pomysł Node'a polegał na tym, żeby ten silnik wykorzystać również poza przeglądarką. Wiesz już, że udało się tego dokonać i to z całkiem niezłym skutkiem. W przypadku renderowania HTML-a i CSS-a sytuacja jest bardzo podobna. Tutaj też przeglądarka musi używać jakiegoś silnika – na przykład, Google Chrome korzysta z Webkita. Czy istnieje więc możliwość użycia go również poza standardową przeglądarką?

Pomyśl tylko! Mając do dyspozycji silnik do renderowania JS-a, HTML-a i CSS-a, a do tego wsparcie Node.js, bylibyśmy w stanie stworzyć nawet... własną przeglądarkę! Jednak przede wszystkim – moglibyśmy korzystać ze wszystkich opcji, które znamy z przeglądarek, nie martwiąc się o ograniczenia.

Na rynku jest kilka narzędzi, które oferują właśnie takie możliwości. Jedno z nich to Electron, napisany przez twórców GitHuba. Narzędzie to dostarcza właśnie tego, czego potrzebujemy – pozwala na zintegrowanie możliwości Node.js z silnikiem Webkit. Dzięki temu możemy tworzyć desktopowe aplikacje okienkowe, nawet z bogatym UI, przy użyciu dobrze znanych technologii. Dodatkowo mamy szansę "zapakować" aplikację do zwykłego pliku .exe. Brzmi zachęcająco?

W ramach ciekawostki możemy dodać, że to właśnie Electron został wykorzystany np. przy tworzeniu bardzo popularnych edytorów Atom i VSCode!

Do dzieła!

Aby pokazać działanie Electrona w praktyce, przerobimy naszą starą aplikację z drugiego submodułu. Chodzi o tę, która pokazywała informacje o systemie użytkownika. Tym razem chcemy, aby była to okienkowa aplikacja z interfejsem stworzonym przy użyciu HTML-a i CSS-a.

Uwaga!

Electron oferuje bardzo dużo możliwości, zwłaszcza jeśli chodzi o łatwą współpracę z różnymi modułami systemowymi (np. Windowsa). My w tym submodule zaledwie "dotkniemy" tego, co oferuje, aby pozwolić Ci na podstawowe poznanie tego narzędzia. Jeśli jednak masz ochotę, zachęcamy również do zapoznania się z dokumentacją.

Zacznij od stworzenia nowego folderu projektu, zainicjowania package.json, oraz pobrania pierwszej paczki:

yarn add electron@5.0.6

Teraz dodaj nowy katalog – app. Będziemy w nim przechowywać naszą aplikację (kod HTML, CSS i JS), którą w założeniu będzie uruchamiał Electron.

Następnie utwórz wewnątrz niego trzy pliki:

index.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>OS info</title>
    <link rel="stylesheet" href="./style.css">
  </head>
  <body>
    <h1>Os info</h1>
    <p id="osPlatform"></p>
    <p id="osArchitecture"></p>
    <script src="./script.js"></script>
  </body>
</html>

script.js

const os = require('os');

const osPlatform = document.querySelector("#osPlatform");
const osArchitecture = document.querySelector("#osArchitecture");

osPlatform.innerHTML = `Platform: <strong>${os.platform()}</strong>`;
osArchitecture.innerHTML = `Platform: <strong>${os.arch()}</strong>`;

style.css

@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');

body {
  background: #282c34;
  color: #fff;
  font-family: 'Open Sans', sans-serif;
  padding: 30px;
}

h1 {
  font-weight: lighter;
}

Czy w kodzie naszej aplikacji jest coś nadzwyczajnego? Większość rzeczy mogłaby być obsłużona przez zwykłą przeglądarkę, ale mamy też fragment, który nie zadziałałby w niej. Chodzi o całą funkcjonalność z otrzymywaniem informacji o systemie.

Electron = Chrome?

Bardzo często przy pisaniu kodu, musimy zastanawiać się, czy dana właściwość albo funkcja będzie działać w konkretnej przeglądarce. Electron ma tę zaletę, że każdy, kto odpali Twoją aplikację, dostanie stronę wyrenderowaną zawsze w taki sam sposób, przez ten sam silnik. Nie musisz obawiać się, że jedna osoba uruchomi go w Chrome'ie, inna w Firefoksie, a jeszcze inna w IE 11.

Pojawia się jednak pytanie – jak możemy ustalić, czy dana właściwość lub funkcja będzie wspierana? W końcu np. Can I Use nie udostępnia tabelki dla narzędzia Electron. Odpowiedź jest prosta. Electron używa Webkita, czyli tego samego silnika, co Chrome. Zawsze patrzymy więc na dostępność właśnie dla tej przeglądarki. A jaką wersję Chrome'a mamy brać pod uwagę? To, jakiej wersji Webkita używa dana wersja Electrona, możesz sprawdzić na stronie narzędzia.

Jazda testowa!

Electron daje szerokie możliwości konfiguracji tego, w jaki sposób ma działać, ale też, w jaki sposób ma wyglądać. Możemy ustawić np. aby pozwalał na kompilację kodu Node.js (dla nas to bardzo ważne) albo, że ma pokazywać na starcie narzędzia developerskie (te, które znamy też z Chrome'a). Możemy ustawić też rozmiar okna, tryb pełnoekranowy itp. Opcji jest całkiem dużo, cała ich lista jest dostępna w dokumentacji.

My na razie spróbujemy odpalić naszą aplikację bez zbędnych ceregieli – z domyślnymi ustawieniami.

Dodaj do package.json nowy skrypt:

"scripts": {
  "start": "electron ./app/index.html"
},

Czy komenda electron ./app/index.html coś Ci przypomina? W podobny sposób kompilowaliśmy poza przeglądarką sam kod JS za pomocą Node'a (np. node app.js).

Uruchom teraz w konsoli komendę yarn start. Przed oczami powinna pokazać Ci się następująca aplikacja:

image

Brawo! Udało Ci się utworzyć pierwszą prawdziwą okienkową aplikację!

Zapewne jednak widzisz już, że nasz kod JS nie do końca prawidłowo zadziałał. Powinien pokazać w HTML-u informacje o systemie użytkownika. Dlaczego tak się nie stało? Kompilacja kodu Node.js to jedna z opcji, która domyślnie jest w Electronie ustawiona na false. Oczywiście zaraz się tym zajmiemy!

Własne ustawienia

Przy początkach nauki Webpacka zawsze bardzo miłą cechą był fakt, że do podstawowego działania wystarczy uruchomienie komendy webpack. Dość szybko jednak do gry wchodził plik webpack.config.js, który pozwalał na dokładniejszą jego konfigurację. Podobnie jest też w przypadku Electrona. Możemy uruchomić go bez żadnej konfiguracji, ale najczęściej chcemy jednak ustawić kilka opcji.

Dodaj więc w głównym katalogu projektu plik main.js. Będzie to plik zajmujący się konfiguracją i uruchomieniem naszej aplikacji za pomocą Electrona.

main.js

'use strict';

const path = require('path');
const { app, BrowserWindow } = require('electron');

function main() {

  // create new window
  let mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true
    },
    width: 300,
    height: 300,
  })

  // load app/index.html as the window content
  mainWindow.loadFile(path.join('app', 'index.html'));

}

app.on('ready', main);

app.on('window-all-closed', function () {
  app.quit();
});

Teraz omówmy sobie ten kod po kolei.

const path = require('path');
const { app, BrowserWindow } = require('electron');

Zaczynamy od zwykłych importów jak path, który pojawił się już w kursie. Będziemy wykorzystywać jedną funkcję z tego modułu – join. Służy ona do tworzenia odpowiedniej ścieżki do pliku, tak żeby działała poprawnie na każdym systemie. Obiektem całej aplikacji jest app. Klasa BrowserWindow służy do tworzenia "okienek". W okienku oczywiście będzie mógł być pokazany nasz plik index.html, ale też każdy inny. Co ważne, każda aplikacja może tworzyć i korzystać z wielu okienek.

function main() {

  // create new window
  let mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true
    },
    width: 300,
    height: 300,
  })

  // load app/index.html as the window content
  mainWindow.loadFile(path.join('app', 'index.html'));

}

To funkcja startowa. Jej zadaniem ma być uruchomienie jednego okna aplikacji o rozmiarach 300x300 i wyrenderowanie w nim naszego pliku index.html z folderu app. Opcja nodeIntegration to coś, o czym mówiliśmy już wcześniej. Pozwala na uruchomienie funkcjonalności Node.js w naszej aplikacji.

app.on('ready', main);

app.on('window-all-closed', function () {
  app.quit();
});

Pozostały kod zajmuje się uruchomieniem funkcji main, kiedy Electron jest już gotowy do pracy oraz zakończeniem procesu, jeśli ktoś kliknie na przycisk "x" w oknie.

Czas wprowadzić jeszcze jedną małą zmianę. Plik main.js jest już gotowy, ale Electron nie domyśli się sam, że ma z niego skorzystać. Musimy zmodyfikować nasz skrypt w package.json:

"scripts": {
  "start": "electron main.js"
},

Teraz spróbuj uruchomić w konsoli komendę yarn start. Efektem powinna być następująca aplikacja:

image

Jeśli wszystko działa, nasza praca dobiegła końca, a mała aplikacja, tym razem w trochę bardziej przyjemnej dla użytkownika wersji, jest gotowa.

Pełna konfiguracja!

Jeśli interesuje Cię jakie inne opcje możemy ustawić w obiekcie okna, zapraszamy do dokumentacji. Electron pozwala w łatwy sposób uczynić nasze okno pełnoekranowym, schować toolbar (belkę z "File", "Edit"...), czy nawet pozbawić go górnego paska systemowego!

Zadanie: więcej praktyki

Aby przećwiczyć używanie Electrona, wykonamy jeszcze jedną małą aplikację. Podobnie jak poprzednie, nie będzie ona bardzo rozbudowana i wykorzystamy w niej tylko namiastkę tego, co oferuje to narzędzie. Niemniej jednak powinna pomóc Ci w pracy z Electronem w przyszłości.

Co zbudujemy?

Naszym zadaniem jest stworzenie okienkowej aplikacji pomagającej zadbać o wzrok podczas pracy przy komputerze. Amerykańscy optycy zalecają, aby dla zdrowia naszych oczu, co 20 minut przerywać pracę przed komputerem na 20 sekund i wykorzystać ten czas na patrzenie w dal, na odległość większą niż 20 stóp (około 6 metrów). Nasza aplikacja ma pomagać w przestrzeganiu tej zasady.

Jej działanie będzie następujące:

image
  1. Na starcie powinna przywitać nas opisem aplikacji oraz buttonem "Start".
  2. Po kliknięciu na button "Start" powinien on zniknąć. Podobnie ukryty powinien zostać opis aplikacji. Zamiast tego użytkownik zobaczy button "Stop", licznik czasu (wartość startowa 20:00), który od tego momentu zacznie odliczać czas w dół, oraz obrazek Work.png.
  3. Gdy licznik dojdzie do zera, powinien zacząć odliczać od nowa (tym razem 20 sekund), a zamiast obrazka Work.png pokazać Rest.png.
  4. Gdy 20 sekund minie, wracamy do sytuacji poprzedniej – ukryty Rest.png, pokazany Work.png i odliczamy od nowa 20 minut. Proces ten (praca – odpoczynek, praca – odpoczynek) ma od tego momentu działać ciągle, aż do kliknięcia buttonu "Stop".
  5. Button "Stop" powinien resetować aplikację, czyli wrócić do jej startowego wyglądu z punktu 1.

Dodatkowo:

  1. Gdy licznik dojdzie do zera, aplikacja powinna uruchomić sygnał dźwiękowy – bell.wav.
  2. Button "X" powinien powodować wyłączenie aplikacji (zamknięcie okna).

Wszystko jasne? Nie wydaje się to być aż takie trudne, prawda?

Etap 1

Wbrew pozorom pracy nie będzie aż tak dużo. Na starcie otrzymamy bowiem pomoc z zewnątrz, od designera. Dostaniemy już zaczęty projekt, na szablonie, który wykonaliśmy do wcześniejszego przykładu z tego submodułu. Designer przygotował wszystkie elementy. My tylko musimy zadbać o logikę aplikacji i tchnąć w nią trochę życia.

Kod startowy możesz pobrać tutaj.

Pamiętaj, aby zainstalować zależności zdefiniowane w package.json za pomocą komendy yarn (odpowiednik npm install).

Jak widzisz, plik main.js, a więc konfiguracja Electrona jest prawie identyczna, jak ta, którą wcześniej napisaliśmy sami. Z tą różnicą, że tutaj designer ustawił trochę większe wymiary, aby łatwiej nam się pracowało. Na gifie oczywiście widzimy, że będziemy musieli trochę te ustawienia zmienić (np. schować ramkę), ale tym zajmiemy się później.

Szablon startowy

Oczywiście nasz szablon startowy, oparty na wcześniejszym zadaniu, jest bardzo prosty. Nie wykorzystujemy w nim Webpacka ani boilerplate'a Create React App. Nie istnieje również funkcjonalność automatycznego odświeżania aplikacji przy zmianach w kodzie. Dzięki tej prostocie możesz łatwiej się w nim odnaleźć, a do naszego małego przykładu będzie on wystarczający.

Przy bardziej zaawansowanych przykładach może Cię zainteresować np. https://github.com/electron/electron-quick-start albo jakiś szablon przygotowany pod Reacta.

Na tym etapie warto wykonać tylko jedną zmianę w konfiguracji. Na czas prac można włączyć narzędzia developerskie. Jest to możliwe za pomocą instrukcji:

mainWindow.webContents.openDevTools();

Dodaj ją do funkcji main w pliku main.js.

Kod w script.js, który przygotował grafik, nie powinien być za to dla Ciebie żadną nowością. To po prostu zwykły reactowy komponent z funkcją render.

Nasza aplikacja w tej chwili, po uruchomieniu komendy yarn start, powinna wyglądać następująco:

image

Wystarczy tej pomocy?

Jeśli wydaje Ci się, że nie potrzebujesz kolejnych wskazówek i jesteś w stanie napisać całą logikę aplikacji bez naszej pomocy, na podstawie opisu i animacji, zabierz się teraz do pracy, a po skończeniu przejdź od razu do ostatniego etapu zadania. Dokończymy w nim konfigurację Electrona (usunięcie ramki).

Etap 2 – przygotowujemy stan

Zacznij od przygotowania stanu. Będziemy przechowywać takie oto informacje:

  • status – załóżmy tutaj trzy możliwości: off, work i rest. Ta zmienna będzie informowała nas, w jakim stanie jest w tej chwili aplikacja, co wykonuje. Domyślna wartość: off.
  • time – zmienna przechowująca czas licznika (w sekundach).
  • timer – zmienna zawierająca przyszły interval. W końcu będziemy musieli odpalać funkcję odmierzającą czas w formie interwału, co sekundę. Na razie może mieć wartość null.

Etap 3 – warunkujemy pokazywanie elementów

Zacznij od warunkowego pokazywania elementów. W tej chwili aplikacja od razu pokazuje wszystko. My chcemy, aby:

  • Opis, czyli dwa akapity z tekstem, były pokazywane tylko wtedy, kiedy status jest równy off. Dla ułatwienia możesz wstawić oba p w jednego diva albo nawet "wyciągnąć" cały opis do zewnętrznego komponentu.
  • Obrazek work.png pokazywał się, kiedy status jest równy work.
  • Obrazek rest.png pokazywał się, kiedy status jest równy rest.
  • Licznik pokazywał się, gdy status jest inny niż off.
  • Button "Start" pokazywał się, gdy status jest równy off.
  • Button "Stop" pokazywał się, gdy status jest inny niż off.

Oczywiście możesz osiągnąć ten efekt na wiele sposobów. Postaraj się zrobić to w taki sposób, aby kod był jak najbardziej czytelny. Możesz nawet podzielić aplikację na kilka komponentów.

render() {

    const { status } = this.state;

    return (
      <div>
        <h1>Protect your eyes</h1>
        {(status === 'off') && <AppDescription />}
        {(status === 'work') && <img src="./images/work.png" />}
        {(status === 'rest') && <img src="./images/rest.png" />}
        {(status !== 'off') && <div className="timer">18:23</div>}
        {(status === 'off') && <button className="btn">Start</button>}
        {(status !== 'off') && <button className="btn">Stop</button>}
        <button className="btn btn-close">X</button>
      </div>
    )
  };

Uwaga – w tym rozwiązaniu opis aplikacji przeniesiono do osobnego komponentu (AppDescription).

class AppDescription extends React.Component {
  render() {
    return (
      <div>
        <p>According to optometrists in order to save your eyes, you should follow the 20/20/20. It means you should rest your eyes every 20 minutes for 20 seconds by looking more than 20 feet away.</p>
        <p>This app will help you track your time and inform you when it's time to rest.</p>
      </div>
    );
  }
};

Etap 4 – formatujemy licznik

Pora na zmianę naszego licznika. Po pierwsze – chcemy, aby pokazywał on czas zapisany w state.time, po drugie – musi to robić w odpowiednim formacie.

W tym kroku stwórz więc nową metodę formatTime, która będzie zwracać czas zapisany w state.time, w formacie mm:ss (np. 12:48). Następnie wykorzystaj tę funkcję w divie .timer.

Uwaga – Jeśli minuta albo sekunda jest mniejsza niż 10, to licznik powinien pokazywać przed liczbą 0, czyli np. dla 13:5 oczekujemy na zwrócenie 13:05.

Etap 5 – pobudzamy "Start" do życia

W tym etapie stwórz nową metodę startTimer i podepnij jej wywołanie do eventu click buttonu "Start".

Funkcja ta powinna tworzyć nowy interval w state.timer, który będzie uruchamiać funkcję odmierzającą czas co sekundę. Metodę tę zrobimy już na zewnątrz startTimer. Nazwiemy ją step. Utwórz ją już teraz, ale na razie jej treść może pozostać pusta. Na razie po prostu wykorzystaj ją przy tworzeniu interwału jako callback.

step = () => {};

startTimer = () => {

  this.setState({
    timer: setInterval(this.step, 1000),
  });

};

Oprócz tego startTimer powinna ustawić też czas na wartość startową – 1200 (1200sec = 20min) i zmienić state.status na work.

Po tych zmianach nasza aplikacja powinna działać następująco:

image

Etap 6 – zaczynamy odliczanie!

Czas popracować trochę nad funkcją step. Na razie po kliknięciu na "Start", jest ona uruchamiana co sekundę, ale nie robi nic.

Twoim zadaniem jest jej modyfikacja. Funkcja ta:

  1. Na starcie powinna zająć się zmniejszeniem licznika o 1s.
  2. Następnie powinna sprawdzić, czy doszliśmy już do 0.
  3. Jeśli tak, to należy zmienić status. Gdy aktualnym był work, to zmieniamy go na rest. Jeśli aktualnym był rest, zmieniamy go na work. Trzeba zacząć odliczanie od nowa. Jeśli zmieniliśmy status z rest na work, to odliczamy od nowa 20 minut (1200s). Jeśli z work na rest – 20 sekund.

Po wykonaniu tych zmian aplikacja powinna działać już całkiem dobrze!

image image

Etap 7 – Przycisk "Stop" i "X"

Pozostały nam dwie przyjemne rzeczy. Musimy zakodować button "Stop" oraz "X".

Button "Stop" powinien uruchamiać nową funkcję stopTimer. Musisz ją napisać, a następnie przypiąć do zdarzenia click na tym buttonie.

Funkcja ta powinna:

  1. Zatrzymać interval state.timer – możesz wspomóc się tu internetem. Poszukaj frazy "clear interval" w wyszukiwarce.
  2. Wyzerować czas (zerujemy state.time).
  3. Zmienić status (ustawiamy state.status na off).

Następnie musisz stworzyć kolejną funkcję closeApp. Powinna być ona przypięta do zdarzenia click na buttonie "X".

Zadaniem tej funkcji będzie po prostu... zamknięcie całej aplikacji. Wystarczy, że użyjesz tutaj instrukcji window.close().

Etap 8 - dodajemy dźwięk! (dla ambitnych)

Na tym etapie dodamy do aplikacji funkcjonalność uruchamiania odgłosu "gongu". Powinna uruchamiać się przy zmianie statusu z work na rest i na odwrót.

Zacznij od dodania nowej metody – playBell. Jej zdaniem będzie po prostu uruchamianie pliku dźwiękowego bell.wav (znajduje się on w folderze sounds).

Następnie funkcję tę wywołuj w metodzie step w sytuacji, gdy odliczono czas do zera (a więc nadszedł moment zmiany statusu).

Pomocna w wykonaniu zadania będzie dokumentacja HTMLAudioElement.

playBell = () => {
  const bell = new Audio('./sounds/bell.wav');
  bell.play();
};

Etap 9 – ostatnie poprawki

Na końcu musimy poprawić jeszcze kilka rzeczy.

  1. Nie chcemy, aby aplikacja była odpalana w okienku z ramką.
  2. Musimy dopasować odpowiednie wymiary.
  3. Powinniśmy wyłączyć DevTools.

Wyłączenie ramki w oknie jest bardzo proste. Wystarczy zmodyfikować konfigurację BrowserWindow w main.js. Do opcji, które już tam są, dodaj frame: false.

Przy okazji, możesz zmodyfikować w tym samym miejscu rozmiar okna. Ustaw następujące wymiary: 520x650.

Na końcu wyłącz jeszcze funkcję, która odpowiadała za uruchamianie narzędzi developerskich. Nie będą nam już potrzebne.

Na końcu nasza aplikacja powinna prezentować się następująco:

image

To już wszystko. Jak widzisz, Electron całkiem przyjemnie towarzyszył nam od początku do końca zadania. W ramach dodatkowego ćwiczenia możesz przejrzeć dokumentację i spróbować pobawić się również z innymi możliwościami i opcjami, które nam dostarcza.

26.5. Publikujemy własną paczkę!

Podczas kursu nie raz było Ci dane korzystać z zewnętrznych paczek. Choć miały różnych autorów, ich pobranie było zawsze dziecinnie proste. Dbał o to, poznany już przez nas NPM, który po uruchomieniu komendy npm install nazwapaczki (np. npm install bootstrap), sam odnajdywał odpowiednie zasoby i ściągał je do naszego projektu. Co więcej, mogliśmy nawet wskazać dokładną wersję, o którą nam chodzi (np. npm install bootstrap 3.3.6).

Skąd NPM pobiera te paczki? W module, w którym wprowadzono NPM-a, była mowa o repozytorium, z którego to narzędzie czerpie. Wiemy zatem, że istnieje po prostu baza paczek, którą przechowuje NPM i to właśnie z niej korzystamy. Zawartość tego repozytorium możemy łatwo sprawdzić – wystarczy skorzystać z wyszukiwarki na npmjs.com.

image

Kto jednak decyduje o tym, jakie paczki się tam znajdą? Kto je tam publikuje? I kto dba o to, żeby były dostępne różne ich wersje? Odpowiedź jest prosta – wszyscy.

Twórcy NPM-a nie zajmują się wyszukiwaniem paczek i dodawaniem wybranych do swojego repozytorium. Zamiast tego, pozwalają robić to wszystkim użytkownikom. Każdy może opublikować swój własny pakiet i decydować jakie wersje będzie udostępniał. Nie istnieje nawet żaden proces wstępnej "walidacji", czy paczka jest sensowna, czy może zwyczajnie nie działa. Tym samym w repozytorium NPM-a możesz znaleźć projekty takich marek jak Google czy Facebook oraz te, które zostały stworzone na potrzeby testów albo dla... żartu (np. paczka is-odd, do sprawdzenia, czy liczba jest nieparzysta).

Skoro publikować mogą wszyscy, to i my! Zrobimy to w ostatniej części tego modułu – opublikujemy własną paczkę w repozytorium NPM-a.

Co z Yarnem?

Yarn "pod maską" korzysta z tego samego repozytorium, co NPM. Komenda yarn add pobiera paczki z dokładnie tego samego miejsca, co npm install.

Budujemy generator ID

Aby jednak mieć co publikować, musimy najpierw przygotować jakąś aplikację. Zajmiemy się czymś, co może nam się przydać w przyszłości – generatorem unikalnych identyfikatorów.

Pierwsze kroki

Proces tworzenia paczki będzie trochę inny niż samej aplikacji. W końcu chcemy przygotować coś, co będzie mogło być używane jako integralna część innych aplikacji.

Nie będziemy więc przygotowywać aplikacji jako takiej. Stworzymy tylko sam moduł, który każdy będzie mógł pobrać do swojego projektu, zaimportować, a następnie użyć do generowania id.

Jednak po kolei :)

Zacznij od stworzenia folderu projektu oraz wygenerowania pliku package.json.

yarn init -y

Teraz zajrzyjmy do środka.

{
  "name": "your-project-folder-name",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

Dotychczas nie interesowało nas za bardzo to, jak package.json "opisuje" nasze aplikacje. Tutaj sytuacja jest inna, bowiem to właśnie z tych informacji będzie korzystać NPM przy pokazywaniu opisu naszej paczki na npmjs.com.

image

Jaka jest aktualna wersja paczki, licencja, na której się opiera, link do strony głównej i repozytorium – to wszystko informacje, które NPM będzie czerpał właśnie z pliku konfiguracyjnego naszej paczki: package.json. Chcemy więc, żeby były one sensowne.

Na screenie widzisz zaznaczony również spory blok z opisem projektu. On jednak nie będzie pobrany z package.json. Zamiast tego NPM poszuka w naszej paczce pliku README.md i to z niego skorzysta przy pokazywaniu opisu.

Czy warto wprowadzić jakieś zmiany w tym, co aktualnie mamy?

Zarówno version, jak i main, który informuje o tym, jaki jest plik "wejściowy" naszej paczki, mogą pozostać bez zmian. Licencja MIT też nie będzie zła. To najpopularniejsza licencja na GH. Daje użytkownikom nieograniczone prawo do używania, kopiowania, modyfikowania i rozpowszechniania dzieła.

Więcej informacji

Jeśli masz ochotę, możesz dodać do package.json trochę więcej informacji: np. kto jest autorem (atrybut author), jakie są słowa kluczowe (keywords), które ułatwią wyszukiwanie paczki, albo kto pomagał przy pracy (contributors) itp.

Jedyną rzeczą, którą koniecznie musisz zmienić, jest name. Każdy pakiet NPM musi mieć unikalną nazwę. Jak zapewne się domyślasz, może to być trudne, szczególnie kiedy chcesz opublikować pakiet, który nie jest wyjątkowo unikatowy – na przykład ten, który będziemy publikować.

Właśnie z tego powodu wykorzystamy możliwość umieszczenia pakietu w tzw. user scope. Oznacza to, że nazwa pakietu będzie zawierała prefix w postaci znaku at @, nazwy użytkownika Twojego konta na NPM, oraz ukośnika /. Konto NPM założymy nieco później, więc na razie zamiast niej użyjemy słowa user. Po tym prefiksie możemy podać dowolną nazwę naszego pakietu, np. randomid-generator. Zatem nazwą Twojego pakietu NPM będzie, na razie, @user/randomid-generator. Docelowo zamienisz słowo user na swoją nazwę użytkownika.

{
  "name": "@user/randomid-generator",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

Uwaga!

NPM nie narzuca, jak powinniśmy nazywać nasze paczki, stosuje jednak filtry antyspamowe. Niektóre nazwy mogą więc zostać odrzucone, jeśli skrypt wykryje jakieś podejrzane słowa.

Tworzymy moduł

Utwórz teraz nowy plik – index.js. To w nim będzie cały nasz kod.

Dlaczego index.js?

Dlaczego akurat taka nazwa pliku? Dlatego, że tak ustaliliśmy już w package.json (pole main). Po co jednak w ogóle musimy nazwę tego pliku wcześniej określić?

W momencie pobierania paczki NPM umieszcza ją w folderze node_modules. Gdy następnie chcemy jej użyć, robimy to następująco:

import paczka from 'paczka';

Czyli na przykład:

import React from 'react';

import (lub require) oczywiście domyślnie poszuka tej paczki w node_modules (dla powyższego przykładu to np. ./node_modules/react). Często jednak w folderze znajduje się więcej niż jeden plik. W takiej sytuacji to właśnie wpis w main podpowie, który jest faktycznym modułem do zaimportowania.

Otwórz ten plik. Zaczniemy od przygotowania eksportu, bowiem tak jak mówiliśmy wcześniej – nie budujemy aplikacji, tylko moduł, który inny będą mogli wykorzystywać w swoich aplikacjach.

const randomId = () => {

}

module.exports = randomID;

W tej chwili nasz kod jest jeszcze dość prosty. Mamy pustą funkcję, która jest eksportowana. module.exports (podobnie jak export default) zapewnia nas, że będzie ona mogła być zaimportowana z zewnątrz.

U nas modułem jest więc tylko jedna funkcja. Oczywiście możemy tworzyć również moduły, w których znajdzie się kilka różnych metod, podobnie jak robiliśmy to często w import ... export.

Główna funkcjonalność

Przyszedł wreszcie czas na zajęcie się już samą funkcją randomID. W założeniu ma być ona bardzo prosta. Chcemy, żeby generowała jakiś długi losowy string z literami i liczbami. Aby sama paczka była trochę ciekawsza, ustalimy jeden parametr idLength, który będzie decydował o ilości znaków w id.

const randomID = (idLength) => {
  let id = '';
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  const charsAmount = characters.length;
  for(let i = 0; i < idLength; i++) {
      id += characters.charAt(Math.floor(Math.random() * charsAmount));
  }
  return id;
}

W samej funkcji nie ma nic nowego. Widzimy tablicę stałych znaków oraz zmienną id. Następnie za pomocą pętli for i funkcji Math.random dodajemy do niej idLength losowych znaków. Na końcu to, co udało nam sie wygenerować, po prostu zwracamy.

Czas na publikację!

Czas na najważniejsze! Zanim jednak opublikujemy naszą paczkę, musimy zrobić jeszcze jedną rzecz – stworzyć konto na npmjs.com.

Tworzymy konto

Możesz to zrobić, wpisując w konsoli komendę npm adduser. Po wybraniu loginu, hasła i emaila, od razu zostajemy automatycznie zalogowani. Nie zapomnij o mailu aktywacyjnym, który powinien znaleźć się na Twojej poczcie. Bez aktywacji konta, nie możemy publikować paczek.

image

Po rejestracji wróć do konsoli w swoim projekcie i uruchom komendę:

npm whoami

Zwróci ona informację o aktualnie zalogowanym użytkowniku. Upewnisz się, dzięki temu, czy na pewno wszystko jest w porządku.

Przy okazji zmienimy nazwę naszego pakietu. Otwórz plik package.json i znajdź w nim pole name. W naszym przykładzie była w nim wartość @user/randomid-generator. Zamień słowo user na swoją nazwę użytkownika.

Publikujemy paczkę

Sama publikacja paczki jest, wbrew pozorom, bardzo krótkim i przyjaznym procesem. Wystarczy wpisać w konsoli komendę:

npm publish --access=public

I... gotowe! :)

image

Komenda ta opublikuje naszą paczkę w repozytorium NPM-a.

Jeśli wszystko poszło dobrze, wejdź na npmjs.com i poszukaj swojej paczki.

Od tej chwili każdy może ją pobrać i używać w swoich projektach!

Testujemy!

Upewnijmy się jeszcze, czy wszystko działa zgodnie z oczekiwaniami. Stwórz teraz nowy folder. W ramach testów wykonamy mini-aplikację wykorzystującą naszą publiczną paczkę!

Zacznij, jak zawsze, od utworzenia package.json. Następnie pobierz swoją paczkę.

yarn add your-package-name

W kolejnym kroku stwórz index.js, który będzie korzystał z naszego "zewnętrznego" modułu.

const randomID = require('your-package-name');

console.log(randomID(10));

Następnie uruchom w konsoli komendę:

node index.js

W konsoli powinniśmy otrzymać teraz losowy ciąg znaków.

Jeśli wszystko działa dobrze, może rozpierać Cię duma. Nie tylko opublikowaliśmy paczkę na repozytorium NPM-a, ale wiemy też, że faktycznie każdy będzie w stanie skorzystać z niej w swoim projekcie.

v1.0, v1.1, v1.2...

W idealnym świecie wszystkie pisane skrypty od razu byłyby doskonałe, a wszelkie poprawki zbędne. Rzeczywistość jest jednak inna. Poprawianie błędów i rozwijanie funkcjonalności to chleb powszedni każdego programisty. Istnieje więc duża szansa, że przyjdzie taki moment, w którym zechcesz opublikować nowszą wersję swojej aplikacji.

NPM pozwala to zrobić w bardzo łatwy sposób.

Wystarczy, że po zmianach, w folderze swojej paczki, uruchomisz komendę:

npm version numer-wersji

np.

npm version 1.0.1

A następnie opublikujesz ją za pomocą:

npm publish

Od tego momentu w repozytorium NPM-a będzie dostępna również nowsza wersja Twojej paczki.

Numeracja wersji

Standardowo używa się semantycznego wersjonowania, w którym numer wersji składa się z trzech liczb. Są to kolejno numery:

  • major, czyli główny numer wersji,
  • minor, czyli pomniejszy numer wersji,
  • patch, czyli numer poprawki.

Jeśli zmieniamy naszą aplikację w gruntowny sposób, który np. wymaga innego sposobu jej stosowania, powinniśmy zmienić numer główny. Na przykład, z wersji 1.7.13 przejść na wersję 2.0.0.

Drugi numer zmieniamy przy dodawaniu nowych funkcjonalności do naszej aplikacji, a ostatni – kiedy tylko naprawiamy błędy.

Oczywiście, jeśli jednocześnie zmieniamy komendy uruchamiające naszą aplikację, oraz wprowadzamy nowe funkcjonalności, a także naprawiamy błędy, to nie zmieniamy wszystkich trzech numerów. W tej sytuacji zmienimy tylko główny numer.

Dodatkową zaletą przejrzystości tego systemu wersjonowania jest fakt, że możemy używać komend npm version major, npm version minor oraz npm version patch. Każda z nich automatycznie zmieni numer wersji na odpowiednim poziomie, zastąpi go w package.json, oraz zapisze nowy commit.

Zadanie: dodajemy opis

Nasza paczka jest już dostępna na NPM-ie, ale z powodu braku opisu, ciężko stwierdzić, do czego dokładnie służy i jak się jej używa.

Twoim zdaniem jest dodanie do folderu paczki pliku README.md, dopisanie do niego kilku informacji o skrypcie, a następnie opublikowanie jej jako nowej wersji – 1.0.1.

Gdy skończysz, wyślij link do paczki opublikowanej w repozytorium Twojemu mentorowi.

26.6. Podsumowanie

Podczas lektury tego modułu mogło pojawić się wrażenie, że niektóre rzeczy zostały przez nas pokazane tylko w pigułce, bardzo pobieżnie. Tyczy się to np. Jimpa czy Electrona, które ledwie "liznęliśmy". Mieliśmy jednak ku temu powody. Przydatnych paczek do Node'a jest multum, a narzędzi o takiej samej funkcjonalności jak Electron można znaleźć w internecie kilka. Nikt z nas nie wie, które konkretnie w przyszłości będą przez Ciebie używane w pracy. Dlatego też, zamiast pokazywać w detalach działanie jednej czy dwóch paczek, woleliśmy, aby ten moduł był dla Ciebie bardziej wyznacznikiem (chociażby w małym ułamku) tego, co może nam zaoferować Node.js. Chcieliśmy również uświadomić Ci, że wbrew temu, co pokazuje mnóstwo tutoriali na rynku, Node.js to nie tylko narzędzie do tworzenia serwerów. Możemy za jego pomocą zrobić znacznie więcej! Jak widzisz, my w tym module nawet nie utworzyliśmy serwera. Zamiast tego pokazaliśmy inne sposoby wykorzystania Node.js.

Przy okazji naszych ćwiczeń, było Ci dane przede wszystkim poznać, jak wygląda sama praca z tym narzędziem. Jak widzisz, ostatecznie nie różni się ona tak bardzo od zwykłej pracy na "froncie".

Submoduł o publikowaniu paczki na NPM możesz potraktować jako "coś ekstra". Być może nieczęsto nadejdzie potrzeba użycia przez Ciebie tej umiejętności. Niemniej jednak warto wiedzieć, jak przebiega cały ten proces.

W kolejnym module wciąż będziemy pracować na backendzie. Tym razem zajmiemy się już jednak bardziej klasycznym tematem – tworzeniem własnego serwera!

;